Como reutilizar código entre tus funciones Lambda con Lambda Layers

La reutilización de código es algo básico cuando uno esta programando. Es de las primeras cosas que te enseñan sin importar con que lenguaje estes empezando y te lo repiten una y otra vez (ya sea indirecta o directamente) a través de diferentes conceptos, como la alta cohesión dentro de GRASP, el Single-Responsibility dentro de SOLID y porsupuesto el mismisimo DRY (Don’t Repeat Yourself).

Es normal pensar en implementar esto mismo en Lambda cuando empiezas a utilizar este servicio. Sin embargo no es tan claro a primera vista como lograrlo, ya que necesitas de otra funcionalidad de Lambda: las Lambda Layers (Capas).

Nota: No esta de más decir que si no has manejado funciones Lambdas aún, es mejor que primero te enfoques en aprender lo básico: Como crear una función de Lambda en AWS en 5 minutos

Caso de uso

Veamos un ejemplo para una librería de NodeJS: Axios. Esta librería se utiliza para hacer peticiones REST. Si ya las has utilizado, sabrás que se puede agregar a tu proyecto usando npm. Para este ejemplo crearemos un par de funciones que llamaremos a través de un API. Dichas funciones a su vez actuaran como un proxy para llamara a The Rick and Morty API.

Para empezar crea un par de funciones Lambda, cada una con la paquetería de axios instalada:

Nota: para instalar la paquetería necesitaras crear un folder en tu computadora y correr los siguientes comandos: npm init y npm install --save axios

getCharacter

Directorio

  • getCharacter
    • node_modules
    • getCharacter.js
    • package-lock.json
    • package.json
Captura de pantalla mostrando la estructura de directorios anterior.

Código

const axios = require('axios')
exports.getCharacter = async (event, context, callback) => {


    const characterId = event.queryStringParameters['id']
    let statusCode = -1
    let body = null
  
  
    try {
      const requestResult = await axios.get(`https://rickandmortyapi.com/api/character/${characterId}/`)
      body = JSON.stringify(requestResult.data)
      statusCode = 200
    } catch (error) {
      body = JSON.stringify({message: error})
      statusCode = 500
    }
    
    return {
        "headers": {
          "Content-Type": "*/*",
          "Access-Control-Allow-Origin": "*"
        },
        "isBase64Encoded": false,
        "statusCode": statusCode,
        "body": body
    }
}

getEpisode

Directorio

  • getEpisode
    • node_modules
    • getEpisode.js
    • package-lock.json
    • package.json
Captura de pantalla mostrando la estructura de directorio anterior.

Código

const axios = require('axios')
exports.getEpisode = async (event, context, callback) => {


    const episodeId = event.queryStringParameters['id']
    let statusCode = -1
    let body = null
  
  
    try {
      const requestResult = await axios.get(`https://rickandmortyapi.com/api/episode/${episodeId}/`)
      body = JSON.stringify(requestResult.data)
      statusCode = 200
    } catch (error) {
      body = JSON.stringify({message: error})
      statusCode = 500
    }
    
    return {
        "headers": {
          "Content-Type": "*/*",
          "Access-Control-Allow-Origin": "*"
        },
        "isBase64Encoded": false,
        "statusCode": statusCode,
        "body": body
    }
}

Como podrás ver getCharacter y getEpisode cada una devuelven un personaje y un episodio de la serie de Rick and Morty respectivamente.

De igual manera crea un API simple en API Gateway que apunte a estas lambdas y publícalo.

El API no es obligatorio pero sirve para que pruebes un caso cercano a un uso real. Puedes omitirlo o si te interesa pero no sabes como puedes revisar: Como crear una REST API con Lambda en AWS en 6 pasos

Captura de pantalla mostrando un API creado en API Gateway llamado SampleAPI.  El API tiene una etapa creada llamada "dev" y 2 recursos llamados: "character" y "episode". Ambos con un método GET.

Como puedes ver en ambas funciones se esta leyendo el parámetro id del API y este valor a su vez se utiliza como parámetro en la petición hecha al API de Rick and Morty.

El código evidentemente es muy simple y podemos agregarle diferentes funcionalidades. Un ejemplo sería ponerle validaciones. Actualmente si mandamos un número como valor para id, todo funciona correctamente para ambas funciones:

Captura de pantalla mostrando una respuesta exitosa tras llamar a la función de getCharacter a través de un API con el path "/character" y con un parámetro "id" con valor de 6.
Captura de pantalla mostrando una respuesta exitosa tras llamar a la función de getEpisode a través de un API con el path "/episode" y con un parámetro "id" con valor de 9.

¿Pero qué pasa si usas una cadena de texto como valor?

Captura de pantalla mostrando una respuesta fallida con código 500 Internal Server Error tras llamar a la función de getEpisode a través de un API con el path "/episode" y con un parámetro "id" con valor de "texto". El error dice "Request failed with status code 500".

Nuestro API devuelve un error 500. Es evidente que algo salió mal al intentar pasar un texto. Como regla siempre es mejor ser lo más explícito posible al momento de regresar errores. Los únicos escenarios que podrían ser una excepción son aquellos en los que haya información que haya que resguardar por cuestiones de seguridad. Sin embargo este no es el caso así que es mejor que regreses explícitamente que el parametro id espera un número.

Para eso simplemente actualiza el código de las lambdas con lo siguiente:

getCharacter

const axios = require('axios')
exports.getCharacter = async (event, context, callback) => {

  let statusCode = -1
  let body = null

  const characterId = event.queryStringParameters['id']
  
  const valid = isIdValid(characterId)

  if (!valid){
    return {
      "headers": {
        "Content-Type": "*/*",
        "Access-Control-Allow-Origin": "*"
      },
      "isBase64Encoded": false,
      "statusCode": 400,
      "body": JSON.stringify({message: "id must be a number"})
    }
  }
  
  try {
    const requestResult = await axios.get(`https://rickandmortyapi.com/api/character/${characterId}/`)
    body = JSON.stringify(requestResult.data)
    statusCode = 200
  } catch (error) {
    body = JSON.stringify({message: error})
    statusCode = 500
  }
  
  return {
      "headers": {
        "Content-Type": "*/*",
        "Access-Control-Allow-Origin": "*"
      },
      "isBase64Encoded": false,
      "statusCode": statusCode,
      "body": body
  }
}

const isIdValid = (id) => {
  
  const idCopy = id+''
  let result = idCopy.match(/^\d+$/)

  return result != null

}

getEpisode

const axios = require('axios')
exports.getEpisode= async (event, context, callback) => {

  let statusCode = -1
  let body = null

  const episodeId = event.queryStringParameters['id']
  
  const valid = isIdValid(episodeId)

  if (!valid){
    return {
      "headers": {
        "Content-Type": "*/*",
        "Access-Control-Allow-Origin": "*"
      },
      "isBase64Encoded": false,
      "statusCode": 400,
      "body": JSON.stringify({message: "id must be a number"})
    }
  }
  
  try {
    const requestResult = await axios.get(`https://rickandmortyapi.com/api/episode/${episodeId}/`)
    body = JSON.stringify(requestResult.data)
    statusCode = 200
  } catch (error) {
    body = JSON.stringify({message: error})
    statusCode = 500
  }
  
  return {
      "headers": {
        "Content-Type": "*/*",
        "Access-Control-Allow-Origin": "*"
      },
      "isBase64Encoded": false,
      "statusCode": statusCode,
      "body": body
  }
}

const isIdValid = (id) => {
  
  const idCopy = id+''
  let result = idCopy.match(/^\d+$/)

  return result != null

}

Ahora al llamar a cualquier de nuestros 2 servicios con una cadena de texto en el id el API nos devuelve un mensaje de error mucho más claro.

Captura de pantalla mostrando una respuesta fallida con código 400 Bad Request, tras llamar a la función de getEpisode a través de un API con el path "/episode" y con un parámetro "id" con valor de "texto". El error dice "id must be a number",

Técnicamente el código funciona bien de esta manera pero es evidente que tiene ciertos problemas:

  1. Hacer actualizaciones al código es más tardado. Si hay que cambiar la forma en la que se valid el parámetro de id se tendrá que actualizar dicho cambio en cada función que tengas.
  2. Verificar consistencia es más difícil. Al tener varias versiones de la misma funcionalidad el código se encuentra mas expuesto a errores humanos, ya que todo depende de que uno se acuerde de cambiar las cosas en todos los lugares correctos.
  3. El tamaño del código se incrementa innecesariamente.

Solución: Lambda Layers

Por lo pronto hay 3 funcionalidades principales que podemos compartir entre funciones, ya que las probabilidades de que difieran entre las mismas son nulas o están muy cerca de serlo:

  1. La paquetería de axios que instalamos en node_modules
  2. La función de validación: isIdValid
  3. El formato de respuesta: headers y isBase64Encoded

Una Lamba Layer puede usar los mismos lenguajes de programación que una función Lambda, por lo que puedes utilizar el siguiente código para crearla.

const isIdValid = (id) => {
  
    const idCopy = id+''
    let result = idCopy.match(/^\d+$/)

    return result != null
  
}

const getResponseObjectTemplate = () => {
    return {
        "headers": {
          "Content-Type": "*/*",
          "Access-Control-Allow-Origin": "*"
        },
        "isBase64Encoded": false,
        "statusCode": null,
        "body": null
    }
}

module.exports.isIdValid = isIdValid
module.exports.getResponseObjectTemplate = getResponseObjectTemplate

Para ello simplemente ve a la opción de Layers(Capas) y selecciona Crear una Capa.

Captura de panta mostrando la sección de Capas/Layers dentro del dashboard de AWS Lambda.

A continuación te aparece un formulario que puedes llenar de la siguiente manera:

  • Nombre: proxyLambdaLayer
  • Descripción: Layer para compartir utilidades para Lambda del API
  • Cargar un archivo Zip: seleccionado
  • Arquitecturas compatibles: vacío
  • Tiempos de ejecución compatibles: Node.js 14.X
Captura de pantalla mostrando la configuración de capa con la información anterior.

Al momento de subir el archivo es importante que subas un zip con un folder llamado nodejs. Esta carpeta debe contener todos los archivos que sean necesarios para tu código. En este caso dado que la layer también va a contener la librería de axios, también se tener que subir el folder de node_modules junto con el package.json.

Dentro de tu zip, la estructura de folders debe quedar así:

  • nodejs
    • node_modules
    • package-lock.json
    • package.json
    • proxyLambdasLayer.js
Captura de pantalla mostrando la estructura de directorios anterior.

Nota: El requerimiento del folder padre (en este caso nodejs) depende del lenguaje de programación que utilices para crear la Lambda Layer.

Una vez hayas llenado el formulario AWS creará la Layer

Captura de pantalla mostrando la creación exitosa de la Layer: proxyLambdasLayer.

Para utilizarla simplemente hay que entrar en las opciones de configuración de las lambdas e ir a la sección correspondiente:

Captura de pantalla mostrrando la pestaña de Código de la función getCharacter.
Captura de pantalla mostrando la sección de Capas vacía en la parte inferior de la pestaña de Condiguración.

Selecciona Añadir una capa. Una vez dentro debes seleccionar la layer que deseas junto con la versión de la misma.

Captura de pantalla mostrando la ventana "Elija una capa" con la capa personalizada "proxyLambdasLayer" y la versión 1.

Nota: Cada vez que actualices una layer AWS creará una versión nueva de la misma. De esta manera puedes tener versionadas las actualizaciones que hagas.

Una vez la hayas agregado puedes verificar que se haya añadido en la sección inferior de la función Lambda.

Captura de pantalla mostrando la sección de Capas en la parte inferior de la pestaña de Configuración, con la layer "proxyLambdasLayer" agregada.

Nota: Una función Lambda puede tener n Layers.

Haz estos pasos con ambas funciones y procede a actualizar el código. Si bien las funciones ya estan referenciando a la Layer correcta falta hacer que el código en verdad haga uso de ella.

getCharacter

Directorio

  • getCharacter
    • getCharacter.js
Captura de pantalla mostrando la estructura de directorio anterior.

Código

const proxyLambdasLayer = require('/opt/nodejs/proxyLambdasLayer')
const axios = require('axios')
exports.getCharacter= async (event, context, callback) => {

  let statusCode = -1
  let body = null

  const characterId = event.queryStringParameters['id']
  
  const responseTemplate = proxyLambdasLayer.getResponseObjectTemplate()
  const valid = proxyLambdasLayer.isIdValid(characterId)

  if (!valid){
     return {
      ...responseTemplate,
      "statusCode": 400,
      "body": JSON.stringify({message: "id must be a number"})
    }
  }
  
  try {
    const requestResult = await axios.get(`https://rickandmortyapi.com/api/character/${characterId}/`)
    body = JSON.stringify(requestResult.data)
    statusCode = 200
  } catch (error) {
    body = JSON.stringify({message: error})
    statusCode = 500
  }
  
  return {
    ...responseTemplate,
    "statusCode": statusCode,
    "body": body
  }
}


getEpisode

Directorio

  • getEpisode
    • getEpisode.js
Captura de pantalla mostrando la estructura de directorio anterior.

Código

const proxyLambdasLayer = require('/opt/nodejs/proxyLambdasLayer')
const axios = require('axios')
exports.getEpisode= async (event, context, callback) => {

  let statusCode = -1
  let body = null

  const episodeId = event.queryStringParameters['id']
  
  const responseTemplate = proxyLambdasLayer.getResponseObjectTemplate()
  const valid = proxyLambdasLayer.isIdValid(episodeId)

  if (!valid){
     return {
      ...responseTemplate,
      "statusCode": 400,
      "body": JSON.stringify({message: "id must be a number"})
    }
  }
  
  try {
    const requestResult = await axios.get(`https://rickandmortyapi.com/api/episode/${episodeId}/`)
    body = JSON.stringify(requestResult.data)
    statusCode = 200
  } catch (error) {
    body = JSON.stringify({message: error})
    statusCode = 500
  }
  
  return {
    ...responseTemplate,
    "statusCode": statusCode,
    "body": body
  }
}


Probablemente solo con ver el código intrínsecamente ya entiendes que está pasando. Pero no esta de más corroborarlo.

const proxyLambdasLayer = require('/opt/nodejs/proxyLambdasLayer')

Esta línea es la que hace referencia a nuestra layer. Con el objeto proxyLambdasLayer ahora tenemos acceso a todas las funciones que hayamos exportado de nuestro archivo proxyLambdasLayer.js en la layer.

AWS por default coloca nuestro código debajo del folder de opt. Tu no tienes control sobre este comportamiento así que simplemente debes recordar usar ese path.

const axios = require('axios')

Esta línea en realidad no cambia en nada. Sin embargo es importante tener presente que ahora la paquetería de node_modules no esta siendo accedida en nuestra misma función, sino en la layer. AWS es lo suficientemente listo para dar acceso a nuestra función a la misma.


Con esto has terminado de implementar tu primera Lambda Layer. Como puedes ver no es complicado y ganas bastantes beneficios al no tener que mantener código duplicado en tus funciones,